Device Shadows: Firmware Guide
Prerequisites
The device must be fully provisioned before shadow operations will work:
- The operational certificate must be active and attached to the device's IoT Thing
- The
iot_default_device_policy(attached automatically during Phase 2 provisioning) grants read/write access to$aws/things/{MPBID}/shadow/name/*
See Verifying a Provisioned Device for a test script that confirms shadow access end-to-end.
MQTT Topics
Replace {MPBID} with the device's uppercase MPBID and {shadowName} with one of the four shadow names (identity, status, config, location).
| Operation | Topic | Direction | QoS |
|---|---|---|---|
| Update shadow state | $aws/things/{MPBID}/shadow/name/{shadowName}/update | Publish | 1 |
| Update accepted | $aws/things/{MPBID}/shadow/name/{shadowName}/update/accepted | Subscribe | 1 |
| Update rejected | $aws/things/{MPBID}/shadow/name/{shadowName}/update/rejected | Subscribe | 1 |
| Desired state changed | $aws/things/{MPBID}/shadow/name/{shadowName}/update/delta | Subscribe | 1 |
| Get current shadow | $aws/things/{MPBID}/shadow/name/{shadowName}/get | Publish | 1 |
| Get accepted | $aws/things/{MPBID}/shadow/name/{shadowName}/get/accepted | Subscribe | 1 |
| Get rejected | $aws/things/{MPBID}/shadow/name/{shadowName}/get/rejected | Subscribe | 1 |
A fully-wired device subscribes to up to 5 topics per shadow (delta, update/accepted, update/rejected, get/accepted, get/rejected) × 4 shadows = up to 20 subscriptions on connect. Size your MQTT client's subscription table accordingly. If your stack has a hard limit, prioritize update/delta and get/accepted — those are required for correct operation.
Shadow Document Structure
Shadow payloads follow AWS IoT's standard shadow JSON format:
{
"state": {
"desired": {
"key": "value"
},
"reported": {
"key": "value"
}
}
}
When publishing, include only the section the device is writing. To report state without touching the desired state:
{
"state": {
"reported": {
"firmwareVersion": "1.2.3",
"status": "idle"
}
}
}
Delta Flow
The delta topic fires whenever the desired state differs from the reported state. The payload contains only the fields that differ — not the full shadow document. It also includes a version field that monotonically increments with each shadow update.
Firmware should:
- Subscribe to
update/deltaon connect for each shadow the device acts on - On receipt, check the
versionfield — ignore the delta if its version is older than the last version the device applied, to avoid acting on stale state after a reconnect - Apply the desired changes locally
- Publish the new reported state back to
updateto confirm the change
To acknowledge a desired change received on the delta topic:
{
"state": {
"reported": {
"setting": "new-value"
}
}
}
AWS IoT will clear the delta for any field where desired and reported now match.
Shadow Field Reference
These schemas are still being finalized. Treat field names and types as stable enough to implement against, but expect additions as hardware features are confirmed.
identity (device reports, read-only from cloud)
| Field | Type | Notes |
|---|---|---|
mpbid | string | 10-char hex device identifier |
serial_number | string | Manufacturer-assigned serial number |
model | string | Device model identifier (e.g. GATEWAY-1000) |
hardware_revision | string | Hardware revision (e.g. RevC) |
manufacturer | string | Manufacturer name |
manufacture_date | string | Manufacturing date (YYYY-MM-DD) |
fw_ver_ble | string | BLE firmware version |
fw_ver_cellular | string | Cellular modem firmware version |
fw_ver_gnss | string | GNSS firmware version |
iccid | string | SIM card ICCID |
imsi | string | International Mobile Subscriber Identity |
mac_address | string | Device MAC address |
status (device reports, read-only from cloud)
| Field | Type | Notes |
|---|---|---|
faults | string | Active fault/error codes (e.g. NO_FAULT) |
last_scan | integer | UTC epoch timestamp (seconds) of when the device last initiated a BLE scan |
mqtt_disconnect_ctr | integer | Count of MQTT broker disconnections |
net_disconnect_ctr | integer | Count of cellular network disconnections |
power_health | string | Battery/power system status (e.g. GOOD) |
power_source | string | Current power source (e.g. battery, usb) |
session | string | Current MQTT or cellular session identifier |
sim_disconnect_ctr | integer | Count of SIM-related disconnections |
config (cloud writes desired, device reads and reports)
This is the only shadow the cloud writes. The device reads desired on connect and on delta, applies the settings, then reports the applied values back.
Conventions: snake_case field names, durations in seconds with a _sec suffix, enums as literal strings.
Parse this shadow tolerantly
Firmware must read config defensively, applying what it understands and not hard-failing on the rest:
- Ignore unknown fields. If a key appears that this build does not recognize, skip it and keep going. Do not reject the whole document.
- Tolerate missing fields. Any field may be absent. Fall back to a compiled-in default so the device is fully operable from first boot without waiting on the cloud.
- Fall back on out-of-range or wrong-type values. Clamp or default, then continue. Do not discard the entire config.
The fleet upgrades firmware asynchronously over long windows, so the schema on one side can run ahead of the other. Reading tolerantly lets both sides keep operating on the parts they understand. Use any CRC or hash for transport and flash integrity, not as a check on the schema's shape: an unknown field must be skipped, not treated as corruption.
Note that the REST API is deliberately stricter than firmware. It rejects unknown fields and out-of-range values so an operator's typo fails loudly, which means firmware should not expect to receive them in practice.
Schedules
ble_scan.schedule and every entry under reporting share one shape: the first run each day is at start_hour UTC, repeating every frequency_sec after it. For example start_hour: 6 with frequency_sec: 14400 runs at 06:00, 10:00, 14:00, 18:00 and 22:00 UTC.
| Field | Type | Notes |
|---|---|---|
start_hour | integer | UTC hour of the first run each day (0–23) |
frequency_sec | integer | Seconds between runs |
Fields
| Field | Type | Notes |
|---|---|---|
enabled | boolean | Master enable/disable for device functionality. Default true |
ble_scan.rssi_threshold | integer | Ignore advertisements weaker than this, in dBm (-100 to -30). Default -80 |
ble_scan.duration_sec | integer | Length of a single scan window, in seconds (1–300). Default 60 |
ble_scan.max_xads_to_collect | integer | Max advertisements to collect per scan (1–1000). Default 1000 |
ble_scan.service_uuids | string[] | Restrict scans to these 16-bit service UUIDs, each 4-char hex (e.g. F5FD). Empty means no UUID filter |
ble_scan.company_codes | string[] | Restrict scans to these Bluetooth SIG 16-bit Company Identifiers, each 4-char hex (e.g. 6501). Empty means no company filter. Default ["6501","0165"] |
ble_scan.schedule | object | When scans run. frequency_sec is never less than duration_sec. Defaults: start_hour 6, frequency_sec 14400 |
mqtt.broker_endpoint | string | Broker hostname only, no scheme or port. The port is fixed device-side. Environment-specific, seeded at provisioning |
mqtt.keep_alive_sec | integer | MQTT keepalive interval, in seconds. Default 60 |
mqtt.session_type | string | PERSISTENT or CLEAN. Default PERSISTENT |
mqtt.packet_timeout_sec | integer | Per-packet acknowledgement timeout, in seconds. Default 60 |
mqtt.last_will_enabled | boolean | Top-level toggle for Last Will and Testament on all connections. Topic, payload and QoS are device-managed. Default false |
antennas.ble | string | INTERNAL, EXTERNAL, or BOTH. Default BOTH |
reporting.cellular_checkin | object | Schedule for cellular check-in. Defaults: start_hour 0, frequency_sec 14400 |
reporting.asset_scan_upload | object | Schedule for uploading collected scans. Defaults: start_hour 9, frequency_sec 14400 |
reporting.gnss | object | Schedule for GNSS fix attempts. Defaults: start_hour 0, frequency_sec 7200 |
location_override.enabled | boolean | false or absent means no override. Default false |
location_override.lat | float | Override latitude (-90.0 to 90.0). Sent together with lon, and only when enabled is true |
location_override.lon | float | Override longitude (-180.0 to 180.0). Sent together with lat, and only when enabled is true |
schema_version (reported-only)
Report schema_version in the config shadow's reported state alongside the applied config. It is the config schema version this firmware build implements, and it is reported-only: the cloud reads it and never writes it, so it never appears in desired.
Bump it only on a breaking change to the config's shape. Additive fields do not bump it, because tolerant readers already ignore keys they do not recognize. The current version is 1.
{
"state": {
"reported": {
"schema_version": 1,
"enabled": true,
"ble_scan": { "rssi_threshold": -80, "duration_sec": 60 }
}
}
}
location (device reports, read-only from cloud)
| Field | Type | Notes |
|---|---|---|
gnss.lat | string | Latitude from GNSS fix |
gnss.lng | string | Longitude from GNSS fix |
gnss.altitude | string | Height above sea level (meters) |
gnss.accuracy | string | Horizontal accuracy (meters) |
gnss.hdop | string | Horizontal Dilution of Precision |
gnss.pdop | string | Position (3D) Dilution of Precision |
gnss.svs | string | Satellites visible/used |
gnss.source | string | GNSS constellation (e.g. GPS, Galileo) |
gnss.status | string | Fix status (e.g. 3D Fix, No Fix) |
gnss.utc | string | UTC timestamp from GNSS receiver |
gnss.fix_duration | string | Seconds to acquire fix |
gnss.fix_age | string | Age of fix (milliseconds) |
gnss.last_successful_fix | string | Timestamp of last good fix |
gnss.assisted_gnss_valid | string | Whether A-GNSS data was current |
towers.timestamp | string | Unix epoch seconds of tower measurement |
towers.lte[].mcc | string | Mobile Country Code |
towers.lte[].mnc | string | Mobile Network Code |
towers.lte[].eutran_cid | string | E-UTRAN Cell Identifier |
towers.lte[].tac | string | Tracking Area Code |
towers.lte[].rsrp | string | Reference Signal Received Power (dBm) |
towers.lte[].rsrq | string | Reference Signal Received Quality (dB) |
towers.lte[].rssi | string | Received Signal Strength (dBm) |
towers.lte[].sinr | string | Signal to Interference plus Noise Ratio (dB) |
On Connect
On every connect the device should GET each relevant shadow to catch any desired state changes that arrived while the device was offline — delta messages are not queued across connections.
Subscribe to update/delta before publishing the GET request. If you publish the GET first, a delta can arrive between the publish and the subscribe, and the device will miss it.
Publish an empty payload to $aws/things/{MPBID}/shadow/name/{shadowName}/get:
{}
The get/accepted response contains the full shadow document including any pending desired state.
Reference Implementation
Boot Shadow GET
Fetches the current state of a named shadow on connect. Adapt the MQTT calls to your platform's client library.
import json, time
from awscrt import mqtt
from awsiot import mqtt_connection_builder
DEVICE_MPBID = "FFFF000001"
MQTT_ENDPOINT = "mqtt.<env>.iot.digital.milwaukeetool.com" # replace <env> with dev/test/stage/prod
OPERATIONAL_CERT_PEM = "..."
OPERATIONAL_KEY_PEM = "..."
SHADOW_NAME = "config" # repeat for each shadow the device uses
conn = mqtt_connection_builder.mtls_from_bytes(
endpoint=MQTT_ENDPOINT,
cert_bytes=OPERATIONAL_CERT_PEM.encode(),
pri_key_bytes=OPERATIONAL_KEY_PEM.encode(),
client_id=DEVICE_MPBID,
clean_session=False, # persistent session: broker queues QoS 1 messages while device is offline
keep_alive_secs=30,
)
conn.connect().result()
get_response = {"data": None, "error": None}
conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get/accepted",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: get_response.update({"data": json.loads(payload)}),
)[0].result()
conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get/rejected",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: get_response.update({"error": json.loads(payload)}),
)[0].result()
conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get",
payload=json.dumps({}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()
for _ in range(30):
if get_response["data"] or get_response["error"]:
break
time.sleep(0.5)
# On embedded platforms, replace the polling loop above with a publish-completion
# callback: register on_get_accepted/on_get_rejected before connecting, then trigger
# the GET from the connect-complete callback. The polling approach is Python-only.
if get_response["error"]:
print(f"GET rejected: {get_response['error']}")
else:
shadow = get_response["data"]
desired = shadow.get("state", {}).get("desired", {})
reported = shadow.get("state", {}).get("reported", {})
print(f"desired: {desired}")
print(f"reported: {reported}")
# Apply any desired state that differs from reported here
Delta Handler
Subscribes to the delta topic and reports back the new state after applying a desired change. Wire this up after connect, before the boot GET above.
last_applied_version = 0 # intentionally 0: on fresh boot, process whatever delta is pending; do NOT persist this across reboots
def apply_desired_state(delta: dict) -> dict:
# Replace with your device's actual state application logic.
# Return the fields that were applied so they can be reported back.
return delta
def on_delta(topic, payload, **kwargs):
global last_applied_version
message = json.loads(payload)
version = message.get("version", 0)
if version <= last_applied_version:
print(f"Stale delta (version {version} <= {last_applied_version}), ignoring")
return
delta = message.get("state", {})
print(f"Delta received for {SHADOW_NAME}: {delta}")
# Apply delta to device — adapt this to your hardware abstraction layer
new_reported = apply_desired_state(delta)
last_applied_version = version
# Report the updated state back so AWS IoT clears the delta.
# Note: [0].result() blocks until PUBACK — on embedded platforms use your
# client's async publish and report from the completion callback instead.
conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/update",
payload=json.dumps({"state": {"reported": new_reported}}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()
conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/update/delta",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=on_delta,
)[0].result()